iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

30天打造一套企業PLM系列 第 14

Day 14:BOM 多階展開——後端演算法+前端樹狀表格

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260831/20161290oz5XR8QkH9.jpg

系列:30 天打造企業級 PLM|面向:全端

問題場景

一台整機的 BOM 展開是幾千個節點。最直覺的寫法,遞迴查每個子件的子件,會產生幾千次資料庫往返;一次全撈又怕撐爆瀏覽器。更麻煩的是髒資料:如果 A 的 BOM 裡有 B、B 的 BOM 裡又有 A(歷史資料常有這種循環),天真的遞迴直接無窮迴圈。今天講 Mini-PLM 的 BOM 全樹 API 怎麼同時解掉這三題。

實機畫面,BOM 樹狀表格,子階展開、縮排呈現:

https://ithelp.ithome.com.tw/upload/images/20260831/20161290rthZrJ05Pe.png

商業邏輯設計

  • 一份結構、三種視角:研發建 BOM、生管展開它排產、採購照它下單。欄位需求各不相同(用量、位置編號、生效版本),結構是同一份
  • BOM 修改的治理:預設一律走變更單(Day 13 的預備版本),直接編輯只在特定條件開放(Draft 版本加 MODIFY_BOM 權限)。放行過的結構不可直改,這是追溯性的底線
  • 用量、位置編號、替代料這些欄位跟著 BOM 行(mp_bom_item)走,不跟著料號走

技術選型與取捨

架構演進:從 EJB 遞迴/CONNECT BY 循環崩潰到 BFS 批次展開

以前在 Oracle Agile PLM 中,多階 BOM 展開是極具破壞力的效能殺手。

在舊 EJB 架構與 Oracle 資料庫環境下:

  1. N+1 查詢與 RMI 負載:若透過 EJB 走訪 BOM,逐層呼叫會產生數千次遠端調用與資料庫查詢;
  2. 循環參照引發崩潰:若改走 Oracle DB 的 CONNECT BY 遞迴查詢,一旦歷史資料中存在循環參照(A 包含 B,B 包含 A),資料庫直接噴出 ORA-01436: CONNECT BY loop in user data,導致整張 BOM 畫面瞬間癱瘓,甚至在 EJB 遞迴走訪時引爆 StackOverflowError

Mini-PLM 採用 BFS 逐層批次查詢 + 記憶體組裝,並內建「祖先路徑循環防禦」與「節點上限截斷」機制,讓複雜的多階 BOM 展開既安全又高效。

遞迴逐節點查 vs BFS 逐層批次查

遞迴逐節點 BFS 逐層批次
查詢次數 O(節點數) O(深度)
程式直覺度
N+1 風險 就是 N+1 本人 天然免疫

選 BFS 批次,查詢數只跟深度成正比,跟節點總數無關。十層、五千節點的樹,只需要約十次批次查詢。BomService.getBomTree() 的 javadoc 直接把複雜度寫進規格:

/**
 * 子層以 BFS 逐層批次查詢(查詢數 ~O(深度),與節點總數無關),不套 redline。
 * 防護:祖先路徑循環偵測(cycleDetected)、深度上限、節點總數上限(truncated)。
 */

前端:一次全載 + 樹狀展開

全樹 API 一次回傳、antd tree table 負責展開收合。沒做懶載入,原因是 BOM 的使用模式是展開來回看、比對,懶載入每展一節點打一次 API 反而卡;上限保護(深度與節點數)已在後端把回應規模鎖住。懶載入是手段不是信仰,資料規模有上限保護時,一次載入的體驗更好。

建 BOM 的 UX:物件拖放與批次掛載

看樹之外還要建樹。一顆一顆料號打進去掛 BOM 是折磨,Mini-PLM 讓料號可以用拖的:搜尋結果、結果追蹤面板裡的品項列都是拖曳來源,拖到 BOM 區放開就掛上去。底層是一套全站共用的 HTML5 原生 dataTransfer 協定,payload 是 JSON:

// 單筆:{ type: "ITEM", id, number }
// 多筆:側欄面板多選後拖出,自動升級成批次 payload
event.dataTransfer.setData("application/json", JSON.stringify({
  type: "ITEM_BATCH",
  items: dragItems.map((itemMeta) => ({ id: String(itemMeta.resourceId), number: itemMeta.number })),
}));

這裡刻意不用 @dnd-kit。dnd-kit 的關鍵約束是拖放兩端必須包在同一個 DndContext 底下(Day 4 表單設計器正是為此把 DndContext 放到頂層),但物件拖放的來源與目標散在完全不同的元件樹——搜尋頁、側欄面板、品項頁——甚至未來可能跨瀏覽器分頁。HTML5 原生協定是瀏覽器層的,天然跨樹;代價是要自己定義 payload 格式與型別防禦(JSON.parse 出來先當 unknown 逐層判別)。元件內排序用 dnd-kit,跨頁面搬運物件用原生協定,兩套並存、各司其職。

接收端 ItemPage.handleBomDrop 有兩層設計值得抄。第一層是守門前置:canDirectEditBom(Draft 初版加 MODIFY_BOM 權限,就是本篇商業邏輯講的直改條件)不成立、或行編輯進行中,直接不收這次 drop,而不是收了再報錯。第二層是部分成功的誠實回報:批次掛載逐筆執行、成功失敗分開記,最後彈出 Drop Summary 總結(總數、成功數、失敗數與逐筆失敗原因)。三十顆料掛上二十八顆,剩兩顆重複或無權限,正確的行為既不是全部回滾、也不是靜默吞掉失敗,而是把帳算清楚給使用者看。這個 Summary 模式在表單夾帶(Day 16 會遇到)原樣複用。

核心內容:BFS 迴圈的三道防護(實碼)

// BFS 逐層展開:level 為「即將產生的子層」深度(1..maxDepth-1)
for (int level = 1; level < maxDepth && !frontier.isEmpty() && !truncated; level++) {
    // 1) 篩出本層可展開的節點(有子 BOM、可解析版本、未形成循環)
    for (BomTreePendingNode pending : frontier) {
        if (pending.ancestorItemIds.contains(childItemId)) {
            // 子件已出現在祖先路徑 → 循環,標記後不往下展開
            pending.node.setCycleDetected(Boolean.TRUE);
            log.warn("BOM 樹展開偵測到循環參照,停止下鑽...");
            continue;
        }
        expandable.add(pending);
        childRevisionIds.add(childRevisionId);
    }

    // 2) 批次查下一層 BOM 行並依父版本分組(一層一查,不是一節點一查)
    List<BomItem> levelItems = bomItemRepository
        .findByParentRevisionIdIn(new ArrayList<>(childRevisionIds));

    // 3) 掛 children 並組下一輪 frontier
    ...
}

第一道防護最有意思:循環偵測用祖先路徑,不用全域 visited。每個 frontier 節點帶著自己的 ancestorItemIds。同一顆螺絲出現在樹的兩個分支是正常的(不是循環),只有子件出現在自己的祖先鏈上才是循環,全域 visited 會把正常的共用料誤殺。偵測到循環是標記該節點(cycleDetected,前端可視化警示)而不是拋錯,一條髒資料不該讓整棵樹看不了。

第二道是深度上限,預設 10 層、外部可調但有硬上限,防的是就算沒有循環、超深的樹也能拖垮回應。第三道是節點總數上限:超限就截斷,回應標 truncated=true,前端顯示已截斷,而不是假裝樹只有這麼大。有上限就要有告知,靜默截斷等於對資料正確性說謊。

改版時的整樹複製:INSERT-SELECT

Day 13 的預備版本要複製整棵 BOM。逐筆 entityManager.persist 複製一千行 BOM,Hibernate 的 dirty checking 與逐筆 INSERT 讓它慢到以秒計;改用 BomBulkCopyDao 的 INSERT-SELECT,一條 SQL 在資料庫端整批複製,千行等級的複製從秒級降到毫秒級。ORM 的甜蜜區是單筆與小批次,整批資料搬移就該回到 SQL。這是 Day 21 壓測抓出「BOM 掛載 2.8 秒」熱點後的修法之一。

踩坑記錄

  • tree table 的 fixed 欄陷阱:固定欄(fixed: 'left')加巢狀展開並用時,展開列的縮排與固定欄的陰影對不齊、橫向捲動時列高錯位。最後的取捨是 BOM 樹頁面不用 fixed 欄,寬欄位需求交給欄位設定(ColumnProfile)讓使用者自選
  • 展開狀態管理:展開後重新整理資料(例如 redline 套用後 refetch),expandedRowKeys 沒保留就整棵收合,使用者剛展到第五層的心血歸零。展開狀態要放 state、以 rowKey 追蹤,refetch 後還原

小結

BFS 批次讓查詢數只看深度,循環偵測用祖先路徑並標記不拋錯,上限保護配 truncated 誠實告知,整樹複製回歸 SQL。四個決策拼起來,幾千節點的 BOM 展開才快得起來、也才炸不掉。明日 Day 15:Redline——改了什麼,怎麼讓簽核的人一眼看懂。


上一篇
Day 13:版本生命週期與改版機制
下一篇
Day 15:Redline——變更差異比對怎麼做
系列文
30天打造一套企業PLM17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言